Autor: Júlio Oliveira
O time está em uma má fase!
Times de futebol passam por diferentes fases, e a impressão é sempre que os resultados resultados anteriores influenciam nos jogos seguintes. A maioria dos modelos que utilizamos para modelagem, assumem que os dados são i.i.d(independent and identically distributed), caso o jogo anterior realmente influencie no resultado, essa premissa não é verdadeira, e talvez um modelo para trabalhar com dados sequenciais funcione melhor.
Para gerar a nossa matriz de transições, precisamos manter a quantidade de jogadores única. Para isso, vamos usar o arquivo de média dos jogadores, apenas para conseguir todos os jogadores que atuaram no ano e a sua posição.
In [1]:
import pandas as pd
In [2]:
medias = pd.read_csv(r'..\..\data\2019\2019-medias-jogadores.csv')
In [3]:
medias.head()
Out[3]:
In [4]:
medias.shape
Out[4]:
In [5]:
medias.columns
Out[5]:
Quantidade única de jogadores é do mesmo tamanho do Dataframe.
In [6]:
qtd_atletas = len(medias['player_id'].unique())
print(qtd_atletas)
Para o contexto desse estudo, vamos analisar cada posição utilizada no Cartola separadamente. Sendo assim criamos uma lista com todas as posições existentes no arquivo médias.
In [7]:
posicoes = medias['player_position'].unique()
Para facilitar a localização de cada jogador nas matrizes que construirmos, vamos criar um índice baseado no rankeamento do "player_id". Como teremos matrizes para cada posição, criamos um ranking para cada posição.
In [8]:
medias['Rank'] = None
for posicao in posicoes:
rank = medias[medias['player_position'] == posicao].player_id.rank(method='min')
rank = rank - 1
medias.iloc[rank.index,-1] = rank
In [9]:
colunas_unicos = ['Rank','player_id','player_position']
atletas = medias[colunas_unicos].drop_duplicates()
In [10]:
atletas.head()
Out[10]:
In [11]:
atletas.shape
Out[11]:
Os resultados das partidas irão gerar informações para a matriz de transição, sendo assim utilizamos o arquivo com todas as partidas de 2019 e selecionamos treino e teste mais adiante.
In [12]:
partidas = pd.read_csv(r'..\..\data\2019\2019_partidas.csv')
Uma das hipóteses testadas nesse estudo, é o impacto da quantidade de gols do time na performance do jogador. Para facilitar a utilização desses dados, vamos normalizar as colunas de quantidade de gols dos time de casa e visitante.
In [13]:
partidas['home_score_norm'] = partidas['home_score'] / max(partidas['home_score'])
partidas['away_score_norm'] = partidas['away_score'] / max(partidas['away_score'])
In [14]:
partidas.head()
Out[14]:
In [15]:
partidas.shape
Out[15]:
Agora, vamos importar os dados de performance de todos os jogadores em todas as rodadas de 2019, deixando uma coluna de identificação da rodada para podermos iterar nela.
In [16]:
df_partidas = pd.DataFrame()
for rodada in range(1,39):
df_rodada = pd.read_csv(r'..\..\data\2019\rodada-{}.csv'.format(rodada))
df_rodada['round'] = rodada
df_partidas =df_partidas.append(df_rodada,sort=False)
In [17]:
df_partidas.shape
Out[17]:
Para o contexto desse estudo não vamos analisar a performance de técnicos.
In [18]:
df_partidas = df_partidas[df_partidas['atletas.posicao_id'] != 'tec']
Para colocar cada jogador em uma posição específica na matriz, vamos trazer a informação de ranking que criamos para o dataframe de partidas.
In [19]:
df_partidas = df_partidas.set_index('atletas.atleta_id').join(atletas.set_index('player_id'))
In [20]:
df_partidas.head()
Out[20]:
In [21]:
df_partidas['Rank']
Out[21]:
Como a base para construção da nossa matriz é a tabela de atletas que criamos, caso algum jogador apareça na rodada e não esteja na tabela, desconsideramos esse jogador.
In [22]:
df_partidas.drop(df_partidas[df_partidas['Rank'].isnull()].index, inplace=True)
In [23]:
df_partidas['Rank'] = df_partidas['Rank'].astype(int)
Para o restante do estudo vamos analisar os resultados para os atacantes.
In [24]:
import numpy as np
In [25]:
posicao = 'ata'
In [26]:
qtd_atletas = len(atletas[atletas.player_position == posicao])
M = np.zeros((qtd_atletas,qtd_atletas))
In [27]:
M
Out[27]:
In [28]:
M.shape
Out[28]:
Para um jogo específico, j1 é o índice do jogador avaliado do time da casa e j2 o índice do jogador avaliado do time visitante.
*Regras para atacantes
In [29]:
df_partidas_posicao = df_partidas[df_partidas['atletas.posicao_id'] == posicao].copy()
In [30]:
for partida in range(len(partidas)-1): #Vamos deixar a última partida de fora para testes
df_rodada = df_partidas_posicao[df_partidas_posicao['round'] == partidas['round'][partida]]
jogadores_casa = df_rodada[df_rodada['atletas.clube_id'] == partidas['home_team'][partida]]
jogadores_visitantes = df_rodada[df_rodada['atletas.clube_id'] == partidas['away_team'][partida]]
for j_casa in range(len(jogadores_casa)):
for j_visitante in range(len(jogadores_visitantes)):
score_casa = 0
score_visitante = 0
pontos_j_casa = jogadores_casa['atletas.pontos_num'].iloc[j_casa]
pontos_j_visitante = jogadores_visitantes['atletas.pontos_num'].iloc[j_visitante]
soma = pontos_j_casa + pontos_j_visitante
if soma != 0:
score_casa = pontos_j_casa / soma
score_visitante = pontos_j_visitante / soma
j1 = jogadores_casa['Rank'].iloc[j_casa]
j2 = jogadores_visitantes['Rank'].iloc[j_visitante]
M[j1,j1] = M[j1,j1] + partidas['home_score_norm'][partida] + score_casa
M[j1,j2] = M[j1,j2] + partidas['away_score_norm'][partida] + score_visitante
M[j2,j1] = M[j2,j1] + partidas['home_score_norm'][partida] + score_casa
M[j2,j2] = M[j2,j2] + partidas['away_score_norm'][partida] + score_visitante
In [31]:
M
Out[31]:
Depois de processar todas as partidas de todos os jogadores, vamos normalizar M para que todas as colunas somem 1.
In [32]:
M = M / np.sum(M,axis=1)
Agora que temos a nossa matriz de transição pronta, podemos calcular a distribuição estacionária.
In [33]:
evals, evecs = np.linalg.eig(M.T)
evec1 = evecs[:,np.isclose(evals, 1)]
evec1 = evec1[:,0]
stationary = evec1 / evec1.sum()
stationary = stationary.real
Por final geramos uma array de tamanho d, lembrando que uma posição i aqui está relacionada a posição i no ranking de ids que criamos no começo do estudo.
Podemos notar que as probabilidades são muito baixas, sendo muito difícil selecionar um jogador apenas pela probabilidades aqui geradas.
In [34]:
stationary
Out[34]:
Podemos verificar por exemplo quem teve probabilidade maior que 1.5%.
O fato dos jogadores como Gabriel e Bruno Henrique aparecem entre os 5 maiores, pode ser um indicador que a regra criada para uma comparação de pontos + quantidade de gols marcada pelo time, pode estar dando maior peso para esses jogadores, o que é uma coisa boa. :)
In [35]:
medias[medias.player_position == posicao][list(stationary > 0.015)]
Out[35]:
Para as posições de defesa, vamos substituir a pontuação referente aos gols marcado pelo time, por uma variável binária, referente a ter sofrido gol no jogo. Caso o time não tenha levado gol no jogo, damos pontuação de 1, se a defesa foi vazada o valor é 0.
No meio-campo fazemos uma combinação das regras de defesa e ataque.
In [36]:
stationaries = {}
for posicao in posicoes:
qtd_atletas = len(atletas[atletas.player_position == posicao])
M = np.zeros((qtd_atletas,qtd_atletas))
df_partidas_posicao = df_partidas[df_partidas['atletas.posicao_id'] == posicao].copy()
for partida in range(len(partidas)-1): #Vamos deixar a última partida de fora para testes
df_rodada = df_partidas_posicao[df_partidas_posicao['round'] == partidas['round'][partida]]
jogadores_casa = df_rodada[df_rodada['atletas.clube_id'] == partidas['home_team'][partida]]
jogadores_visitantes = df_rodada[df_rodada['atletas.clube_id'] == partidas['away_team'][partida]]
for j_casa in range(len(jogadores_casa)):
for j_visitante in range(len(jogadores_visitantes)):
score_casa = 0
score_visitante = 0
pontos_j_casa = jogadores_casa['atletas.pontos_num'].iloc[j_casa]
pontos_j_visitante = jogadores_visitantes['atletas.pontos_num'].iloc[j_visitante]
soma = pontos_j_casa + pontos_j_visitante
if soma != 0:
score_casa = pontos_j_casa / soma
score_visitante = pontos_j_visitante / soma
def_n_vazada_casa = 0 if partidas['away_score_norm'][partida] > 0 else 1
def_n_vazada_visitante = 0 if partidas['home_score_norm'][partida] > 0 else 1
if posicao == 'ata':
pontos_casa = partidas['home_score_norm'][partida] + score_casa
pontos_visitante = partidas['away_score_norm'][partida] + score_visitante
elif posicao == 'mei':
pontos_casa = partidas['home_score_norm'][partida] + def_n_vazada_casa + score_casa
pontos_visitante = partidas['away_score_norm'][partida] + def_n_vazada_visitante + score_visitante
else:
pontos_casa = def_n_vazada_casa + score_casa
pontos_visitante = def_n_vazada_visitante + score_visitante
j1 = jogadores_casa['Rank'].iloc[j_casa]
j2 = jogadores_visitantes['Rank'].iloc[j_visitante]
M[j1,j1] = M[j1,j1] + pontos_casa
M[j1,j2] = M[j1,j2] + pontos_visitante
M[j2,j1] = M[j2,j1] + pontos_casa
M[j2,j2] = M[j2,j2] + pontos_visitante
M = M / np.sum(M,axis=1)
evals, evecs = np.linalg.eig(M.T)
evec1 = evecs[:,np.isclose(evals, 1)]
evec1 = evec1[:,0]
stationary = evec1 / evec1.sum()
stationary = stationary.real
stationaries[posicao] = stationary
No cálculo da distribuição, deixamos a última rodada de 2019 de fora, agora podemos utilizá-la para testar o nosso modelo.
In [37]:
rodada = 38
Primeiro vamos criar um DataFrame somente com as informações da rodada e colocar as probabilidades que encontramos referente a cada jogador.
In [38]:
df_rodada = df_partidas[df_partidas['round'] == rodada].copy()
df_rodada['Rank'] = df_rodada['Rank'].astype(int)
df_rodada['probs'] = 0
In [39]:
for jogador in range(len(df_rodada)):
posicao = df_rodada.iloc[jogador]['player_position']
rank = df_rodada.iloc[jogador]['Rank']
if rank:
df_rodada.iloc[jogador,-1] = stationaries[posicao][rank]
Vamos utilizar também do recurso de status e só trabalhar com jogadores em status Provável.
In [40]:
df_rodada = df_rodada[df_rodada['atletas.status_id'] == 'Provável'].copy()
In [41]:
df_rodada.head()
Out[41]:
Agora que temos as probabilidades de cada jogador, precisamos gerar a melhor escalação possível de acordo com as restrições de quantidade de "cartoletas" e quantidade de jogadores por posição.
Para isso vamos usar Programação Linear para maximizar a soma das probabilidades, restringindo a escalação escolhida e a quantidade de cartoletas.
Para os exemplos abaixo, vou usar a formação 4-3-3 e um total de 140 cartoletas.
In [42]:
formacao = {
'ata': 3,
'mei': 3,
'lat': 2,
'zag': 2,
'gol':1
}
cartoletas = 140
Podemos representar esse problema com a seguinte notação matemática.
Restrições: $$ \sum^n_{i=1}{c}_{i} * {y}_{i} <= 140 $$ $$ \sum^n_{i=1}{a}_{i} * {y}_{i} = 3 $$ $$ \sum^n_{i=1}{m}_{i} * {y}_{i} = 3 $$ $$ \sum^n_{i=1}{l}_{i} * {y}_{i} = 2 $$ $$ \sum^n_{i=1}{z}_{i} * {y}_{i} = 2 $$ $$ \sum^n_{i=1}{g}_{i} * {y}_{i} = 1 $$
As variáveis que entraram na equação são relacionadas as posições, custo e probabilidade. Sendo assim, vamos criar dicionários com cada uma dessas informações relacionadas ao nome do jogador para facilitar a montagem do problema.
In [43]:
df_rodada.set_index('atletas.slug',inplace=True)
z = df_rodada['probs'].to_dict()
c = df_rodada['atletas.preco_num'].to_dict()
dummies_posicao = pd.get_dummies(df_rodada['atletas.posicao_id'])
dummies_posicao = dummies_posicao.to_dict()
Primeiro, iniciamos o problema de otimização e definimos uma função objetivo.
In [44]:
from pulp import LpMaximize, LpProblem, lpSum, LpVariable
prob = LpProblem("Melhor_Escalacao", LpMaximize)
y = LpVariable.dicts("Atl",df_rodada.index,0,1,cat='Binary')
prob += lpSum([z[i] * y[i] for i in y])
Agora adicionamos todas as restrições e calculamos.
In [45]:
prob += lpSum([c[i] * y[i] for i in y]) <= cartoletas, "Limite de Cartoletas"
prob += lpSum([dummies_posicao['ata'][i] * y[i] for i in y]) == formacao['ata'], "Quantidade Atacantes"
prob += lpSum([dummies_posicao['lat'][i] * y[i] for i in y]) == formacao['lat'], "Quantidade Laterais"
prob += lpSum([dummies_posicao['mei'][i] * y[i] for i in y]) == formacao['mei'], "Quantidade Meio"
prob += lpSum([dummies_posicao['zag'][i] * y[i] for i in y]) == formacao['zag'], "Quantidade Zagueiros"
prob += lpSum([dummies_posicao['gol'][i] * y[i] for i in y]) == formacao['gol'], "Quantidade Goleiro"
In [46]:
prob.solve()
Out[46]:
Os jogadores escalados que maximizam as probabilidades dentro das restrições, ficam com o valor 1 para a variável de atletas.
In [47]:
escalados = []
for v in prob.variables():
if v.varValue == 1:
atleta = v.name.replace('Atl_','').replace('_','-')
escalados.append(atleta)
print(atleta, "=", v.varValue)
In [48]:
colunas = ['atletas.posicao_id','atletas.clube.id.full.name','atletas.pontos_num','atletas.preco_num']
df_rodada.loc[escalados][colunas]
Out[48]:
Podemos verificar qual foi o total de pontos que essa escalação somaria na última rodada.
In [49]:
df_rodada.loc[escalados]['atletas.pontos_num'].sum()
Out[49]:
Também o custo total.
In [50]:
df_rodada.loc[escalados]['atletas.preco_num'].sum()
Out[50]:
Agora a cereja do bolo...
Até aqui, o nosso modelo compara posição por posição, os jogadores contra seus adversários. Simulando, de certa forma, qual jogador que deveríamos escolher, para cada posição, considerando a sequência de jogos que esse jogador teve.
No cálculo da distribuição estacionária, podemos notar que as probabilidades são muito semelhantes, ficando difícil escolher jogadores com muita certeza. Faz todo sentido, o futebol é rodeado de incertezas. No entanto essa estatística pode nos ajudar a escalar o time automaticamente.
Podemos então utilizar a probabilidade gerada pela cadeia de Markov para selecionar um time baseado em alguns palpites que temos para os jogos. Vamos fazer um sistema simples, onde distribuimos 10 pontos, para a importância de alguns fatores. Por exemplo, eu considero que jogar em casa é um fator importante, e também gosto de apostar em times que além de jogar em casa, vão pegar adversários que nas últimas posições no campeonato. Então, dei as seguintes notas para a última rodada:
In [51]:
jogar_em_casa = 5
times = {
'Internacional':3,
'Fortaleza':2
}
Agora, aumentamos as probabilidades dos jogadores que se enquadram nessa regra, multiplicando o seu valor atual, pela porcentagem de pontos que demos a ele, por exemplo:
In [52]:
times_casa = partidas[partidas['round'] == rodada]['home_team']
df_rodada.loc[df_rodada['atletas.clube_id'].isin(times_casa),'probs'] = df_rodada.loc[
df_rodada['atletas.clube_id'].isin(times_casa),'probs'] * (jogar_em_casa / 10 + 1)
In [53]:
for time in times:
df_rodada.loc[df_rodada['atletas.clube.id.full.name'] == time,'probs'] = df_rodada.loc[
df_rodada['atletas.clube.id.full.name'] == time,'probs'] * (times[time] / 10 + 1)
In [54]:
z = df_rodada['probs'].to_dict()
Podemos otimizar a equação novamente.
In [55]:
from pulp import LpMaximize, LpProblem, lpSum, LpVariable
prob = LpProblem("Melhor_Escalacao", LpMaximize)
y = LpVariable.dicts("Atl",df_rodada.index,0,1,cat='Binary')
prob += lpSum([z[i] * y[i] for i in y])
In [56]:
prob += lpSum([c[i] * y[i] for i in y]) <= cartoletas, "Limite de Cartoletas"
prob += lpSum([dummies_posicao['ata'][i] * y[i] for i in y]) == formacao['ata'], "Quantidade Atacantes"
prob += lpSum([dummies_posicao['lat'][i] * y[i] for i in y]) == formacao['lat'], "Quantidade Laterais"
prob += lpSum([dummies_posicao['mei'][i] * y[i] for i in y]) == formacao['mei'], "Quantidade Meio"
prob += lpSum([dummies_posicao['zag'][i] * y[i] for i in y]) == formacao['zag'], "Quantidade Zagueiros"
prob += lpSum([dummies_posicao['gol'][i] * y[i] for i in y]) == formacao['gol'], "Quantidade Goleiro"
In [57]:
prob.solve()
Out[57]:
Por fim geramos uma nova escalação, que levou em consideração os pesos que colocamos acima.
In [58]:
escalados = []
for v in prob.variables():
if v.varValue == 1:
atleta = v.name.replace('Atl_','').replace('_','-')
escalados.append(atleta)
print(atleta, "=", v.varValue)
Ao avaliar a escalação abaixo, geramos dessa vez uma pontuação de 81 pontos, 60% a mais que o resultado anterior.
Outro ponto interessante é que usamos menos cartoletas do que na escalação anterior. Uma oportunidade é usar esse modelo para fazer escalações mais baratas, quando o objetivo for valorização. Para isso, basta colocar o limite que deseja no total de cartoletas.
In [59]:
colunas = ['atletas.posicao_id','atletas.clube.id.full.name','atletas.pontos_num','atletas.preco_num']
df_rodada.loc[escalados][colunas]
Out[59]:
In [60]:
df_rodada.loc[escalados]['atletas.pontos_num'].sum()
Out[60]:
In [61]:
df_rodada.loc[escalados]['atletas.preco_num'].sum()
Out[61]: