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. 🙂
- Wstęp
- Cel projektu
- Założenia dotyczące projektu
- Opis zbioru danych
- Wczytanie danych
- Przygotowanie zbiorów wejściowych
- Przygotowanie danych do modelowania
- Modelowanie
- Predykcja zwycięzcy MŚ
- 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 i 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:
- Część ucząca – wykonam na niej walidację krzyżową i posłuży mi ona do poszukiwania parametrów modelu.
- 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:
- GradientBoostingClassifier.
- Dobór parametrów z użyciem RandomizedSearchCV.
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:
- XGBoost.
- Dobór parametrów z użyciem RandomizedSearchCV.
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 :-)
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.
Paweł, dziękuję za komentarz i miłe słowa 🙂
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 🙂
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!
Hej Aleksander,
Ogromnie się cieszę, że wpis Ci się spodobał i, że wyniosłeś z niego coś dla siebie 🙂
Dzięki!