Machine Learning — Descobrindo a posição de jogadores do FIFA © 2017

Introdução

No site Codenation, existem diversos desafios de Data Science para aplicar de forma prática os conhecimentos de Machine Learning. Um destes desafios consiste em descobrir a posição dos jogadores de futebol do jogo FIFA© 2017 baseado nas características destes jogadores.

O modelo descrito neste artigo ainda pode ser melhorado e pode ser atualizado em uma próxima publicação. Com ele obtive 87.43% de acerto. Vou tentar colocar o máximo de código explicando o passo a passo de maneira simples.

Página do desafio: https://www.codenation.com.br/journey/data-science/challenge/mlearning-1.html

Desta forma, o desafio é um problema de classificação. Deve-se ler dois arquivos fornecidos pelo Codenation para este desafio:

  • Train.csv: neste arquivo, os dados para o treinamento do modelo são disponibilizados juntamente com seus respectivos rótulos;
  • Test.csv: neste arquivo, os dados para o teste e avaliação no site são disponibilizados. Desta vez não há rótulos para os dados, assim deve usar o modelo construído para classificar estes dados e enviar um arquivo contendo estas classificações para o site avaliar o seu modelo.

Desenvolvimento

Primeiramente, devemos importar as bibliotecas utilizadas:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import GridSearchCV
from sklearn import preprocessing
from sklearn.neural_network import MLPClassifier
from sklearn.naive_bayes import BernoulliNB
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
import collections as cll
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline

Depois, vamos ler os arquivos de treinamento e teste, além de criar um DataFrame que será enviado para o Codenation:

train = pd.read_csv('train.csv')
test = pd.read_csv('test.csv')
ans = pd.DataFrame()

Podemos ver que existem exatamente 4829 em cada um dos conjuntos e que o conjunto de treinamento tem uma coluna a mais, esta é a coluna “preferred_pos” que é o rótulo que procuramos classificar.

Agora, vamos separar os registros de seus respectivos rótulos. X_train será o conjunto de dados de entrada no modelo enquanto y_train a saída (rótulo) esperada. Para o conjunto de teste, não haverá rótulo esperado. Ainda vamos colocar o “ID” no DataFrame da submissão, este é apenas um código identificador de cada jogador.

X_train = train.drop(['preferred_pos', 'ID'], axis = 1)
y_train = train['preferred_pos']
X_test = test.drop(['ID'], axis = 1)
ans['ID'] = test['ID']

Pré-processamento de dados

Os dados necessitam de um tratamento prévio para depois servirem de entrada as técnicas de Machine Learning. Uma das primeiras coisas a serem feitas deve ser a procura por valores faltando (NULLs) no conjunto de dados. O código abaixo é usado para isto:

Como a saída de ambos cálculos é 0, não há valores faltando no conjunto de dados.

Outro fator importante a ser analisado é a quantidade de jogadores de cada posição. Para ver isto de maneira simples, basta fazer um gráfico de barras:

sns.countplot(y_train)

No gráfico, vemos que existem posições com praticamente 1000 jogadores enquanto há posições que não existem nem 10 jogadores. Ou seja, nosso conjunto de dados está desbalanceado. Idealmente, deve realizar um oversampling e/ou undersampling, todavia como a métrica objetivo para o desafio é apenas a acurácia (recall não é considerado), os modelos propostos serão treinando com o conjunto de dados desbalanceado.

Por que a acurácia diminuí quando se faz o balanceamento de um conjunto de dados muito desbalanceado? A acurácia nos diz simplesmente quantos registros foram classificados de forma correta pelo nosso modelo, suponhamos um conjunto de dados fictício sobre uma doença rara qualquer, nele há 100000 registros, sendo que apenas 5 destes são diagnosticados com a doença. Se pensarmos em um classificador “burro” que classifica tudo como saudável, a acurácia deste modelo será muito alta (99.995%). Assim, caso haja um balanceamento, a quantidade de registros da classe majoritária iria cair muito ou a quantidade de registros da classe minoritária iria subir muito (dados sintéticos), o que faria o modelo aprender as características da doença, mas aprender mau, já que a maioria dos registros seriam sintéticos ou não haveriam registros suficientes para aprender.

Já que muito provavelmente todos ou quase todos registros das posições que existem poucos jogadores serão classificados de forma errada, os rótulos das posições com menos de 25 jogadores são trocados por um rótulo de uma posição que existem muitos jogadores:

for i in range(len(y_train)):
if y_train[i] == 'prefers_rwb' or y_train[i] == 'prefers_lwb' or y_train[i] =='prefers_lw' or y_train[i] =='prefers_cf' or y_train[i] =='prefers_rw':
y_train[i] = 'prefers_cb'

No total, cada jogador tem 54 atributos. Destes 54 apenas 3 não são variáveis numéricas, a nacionalidade (nacionality), tipo de corpo (body_type) e pé preferido (preferred_foot). Como a maioria dos modelos de Machine Learning não trabalha com variáveis do tipo string, estas variáveis precisam de um tratamento prévio. A nacionalidade é descartada já que existem mais de 50 possibilidades e não influencia diretamente na posição onde o jogador joga.

A variável preferred_foot pode ser “right” ou “left”. Então são criadas duas variáveis booleanas “right” e “left” para cada jogador, indicando qual é o pé preferido. Para fazer isto, bastam duas linhas de código, uma para criar as variáveis extra e outra para adicioná-las ao conjunto de dados já excluindo a variável antiga (string):

preferred_foot = pd.get_dummies(X_train['preferred_foot'])
X_train = pd.concat([X_train, preferred_foot], axis = 1).drop(['preferred_foot'], axis = 1)

Por ultimo, o tipo de corpo. Esta é a variável que mais deu trabalho, se analisarmos as possibilidades para esta variável assumir no conjunto de treinamento e teste, vemos que há algo de estranho nestes dados:

Os tipos de corpo são: “Stocky”, “Normal” e “Lean”. Courtois, Akinfenwa e Neymar são nomes de jogadores. Dando uma pesquisa, fiz uma associação simples destes tipos de corpo estranhos da seguinte forma:

  • Courtois -> Lean
  • Akinfenwa -> Stocky
  • Neymar -> Lean
for i in range(len(X_train)):
if X_train['body_type'][i] == 'Courtois':
X_train['body_type'][i] = 'Lean'

for i in range(len(X_test)):
if X_test['body_type'][i] == 'Akinfenwa':
X_test['body_type'][i] = 'Stocky'
if X_test['body_type'][i] == 'Neymar':
X_test['body_type'][i] = 'Lean'

Após este tratamento, basta criarmos as variáveis booleanas assim como criamos para o pé preferido. Mas neste caso serão criados três atributos para cada jogador, sendo um para cada possibilidade de tipo de corpo

body_shape = pd.get_dummies(X_train['body_type'])
X_train = pd.concat([X_train, body_shape], axis = 1).drop(['body_type'], axis = 1)

Por último, mas não menos importante, é necessário normalizar os dados. A normalização faz que todos os dados fiquem numa mesma escala(média ~ 0, desvio padrão = 1), isto pode ajudar alguns modelos a serem treinados mais rápido além de desenviesar o modelo de alguma possível variável com valores discrepantes comparada com as demais. Usamos a biblioteca sk-learn para fazer isto facilmente também:

Scaler = preprocessing.StandardScaler()
X_train = Scaler.fit_transform(X_train)

Modelos e avaliação

Finalmente, aos modelos propostos para este problema. Para avaliar os modelos, foi usada a validação cruzada. Esta técnica divide o conjunto original de dados em k sub-conjuntos, em seguida utilizar k− 1 subconjuntos para treinar classificadores e depois usa-los para classificar um outro sub-conjunto. Este processo é repetido para cada um dos k subconjuntos, assim todos os subconjuntos são usados para o treinamento e teste. Uma dificuldade da validação cruzada é que, como o processo se repete k vezes, o custo computacional pode se tornar alto. Para o número de sub-conjuntos é comum usar 10, a implementação disto é bem simples:

k_fold = KFold(n_splits=10, shuffle=True, random_state=42)
scoring = 'accuracy'

Random Forest

A primeira técnica a ser utilizada é a Random Forest com 100 estimadores.

Na média, obtém-se 86.35%, o que é um bom resultado para este conjunto de dados. Vamos ver como outros classificadores se comportam.

Naive Bayes

O Naive Bayes é uma técnica estatística que geralmente não apresenta bons resultados em conjuntos de dados com muitas variáveis correlacionadas, infelizmente este é o caso deste problema, com 78.19% de acurácia média:

MLP

Uma rede neural simples pode apresentar bons resultados para problemas como este. Muitas vezes seus resultados são comparados com o da Random Forest, aqui consegui 84.1% na acurácia.

XGBoost

Este é uma algoritmo extremamente poderoso, muito usado em competições do Kaggle. Executando uma este algoritmo obtive um resultado pouco superior ao da Random Forest, então resolvi otimizar com uma busca em grade de parâmetros. No melhor dos modelos, obtive 87.31% de acurácia. Também podemos ver os parâmetros que resultaram neste modelo abaixo:

Importância dos atributos

Olhando o ranking no Codenation, todos resultados no top-5 estão na faixa de 87%, assim como o modelo de XGBoost que está descrito acima. Mas para entender melhor quais dados são importantes para realizar esta classificação, não é difícil fazer uma gráfico indicando quais são os atributos principais. Assim usando a Random Forest para montar um gráfico com as 15 características mais importantes para a classificação, podemos fazer isto da seguinte forma:

# Treina a Random Forest e calcula a importância de cada atributo
rfc.fit(X_train, y_train)
importances = rfc.feature_importances_
indices = np.argsort(importances)
# Seleciona os 15 mais importantes e faz o gráfico
quant = 15
plt.title('Feature Importances')
plt.barh(range(quant), importances[indices][-quant:], color='b', align='center')
plt.yticks(range(quant), [features[i] for i in indices[-quant:]])
plt.xlabel('Relative Importance')
plt.show()

Submissão das classificações

Como o XGBoost foi o algoritmo que melhor conseguiu classificar as amostras de treinamento, está na hora de usa-lo no conjunto de teste para classificar estes jogadores.

Todo o conjunto de teste deve ser pré-processado exatamente da mesma forma que o conjunto de treinamento. Podemos fazer isto rapidamente da seguinte forma:

body_shape = pd.get_dummies(X_test['body_type'])
X_test = pd.concat([X_test, body_shape], axis = 1).drop(['body_type'], axis = 1)
X_test.drop(['nationality'], axis = 1, inplace = True)
preferred_foot = pd.get_dummies(X_test['preferred_foot'])
X_test = pd.concat([X_test, preferred_foot], axis = 1).drop(['preferred_foot'], axis = 1)
X_test = Scaler.fit_transform(X_test)

Agora basta classificar o conjunto de teste e fazer o arquivo da submissão!

prediction = best_xgb.predict(X_test)
ans['preferred_pos'] = prediction
ans.to_csv('answer.csv', index=False)

Esta submissão foi enviada ao Codenation para avaliação e resultou em 87.43% de acurácia, o que não é nada mau para este desafio.

Considerações finais

Como foi comentado na introdução deste artigo, os modelos propostos aqui ainda podem ser melhorados e outros modelos podem ser propostos para solucionar este mesmo desafio.

Talvez eu faça um outro artigo visando melhorar o desempenho neste desafio, já neste, o objetivo não foi otimizar os modelos. Ainda, todo o código descrito aqui está disponível em minha página do GitHub:

https://github.com/lmeazzini/FIFA-Player-Position

PS: Se curtir, dá uma aplaudida ;D